iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

雖然昨天說要開始來做上下文壓縮,但是,在這之前,我覺得應該要先來做可以看上下文佔用的功能,這樣我們才可以有感地體驗到壓縮的感覺。
今天,我們完成它的前半部分,馬上開始吧!


即時更新的佔用顯示

先看一下完成的效果如下:

右方顯示 [1.9k/16.0k] 11.8%。

https://ithelp.ithome.com.tw/upload/images/20261009/20183608hwnzMsFS13.png

輸入完,馬上多了一點:

變成 12% 了!

https://ithelp.ithome.com.tw/upload/images/20261009/201836086hdrb0PCYB.png

模型回答完,又多了更多:
https://ithelp.ithome.com.tw/upload/images/20261009/20183608BuzagJzTZ7.png

而當輸入訊息太長時也無需擔心,佔用顯示會自動消失,如下兩圖:
https://ithelp.ithome.com.tw/upload/images/20261009/20183608s3mRvewa3r.png
https://ithelp.ithome.com.tw/upload/images/20261009/20183608S9zbVFzNIn.png


數據從何而來?

分為兩種數據形式:

  1. 準確的 token 數:由 ollama 提供,但要在「輸出完才能得到」。
  2. 大致估算:由我們自己寫一個函數來估算出大致消耗的 token。

由於 Ollama 提供的數據要等到輸出完畢才能取得,沒辦法做到邊輸入邊進行即時更新,因此我們會以估算的方式作為輔助。


估算 token

這裡,先來實作第二點提到的函數:
這個函數只專注於把「傳入的文字估算為 token 數」。

# src/meowgent/agent.py
...

def estimate_token(text: str) -> int:
    """ 估算出傳入文字的 token 量 """
    
class Agent():
	...

一般來說,中日韓文與全形標點一個字元大致約為「1.3 token」,而一般的半形英文、數字與標點符號則是約「3.5 個字元」為 1 token,
我們先用正則抓出前者:

同樣是採用了預編譯。

# src/meowgent/agent.py
...
RX...
RX_CJK_PATTERN = re.compile(r'[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]')

...

這些看起來像亂碼的是 Unicode 編碼點(Code Point)的十六進位代號:

  • \u4e00-\u9fff:中日韓統一表意文字。
  • \u3000-\u303f:中日韓符號和標點(全形,涵蓋全形空格(\u3000)、書名號(《》)、引號(「」『』)、句號(。)、頓號(、)等)。
  • \uff00-\uffef:半形及全形形式(涵蓋全形英文字母、全形數字、全形標點(,、!、?、:)及日語半形假名)。

首先,先把找到的樣式數量做統計(cjk_count),然後用總長扣掉就是剩餘其他字元的長度,拿到數量後進行計算,最後轉為整數。

這邊計算出來的原本是浮點數,而因為 token 沒有小數點,所以直接拿 int() 轉換,
它的方式為「向 0 靠攏」,如果是 3.8 會變 3,-2.5 會變 -2(這邊不會有負數出現,只是解釋 int())。

# src/meowgent/agent.py
...

def estimate_token(...) -> ...:

	# 中日韓文、全形標點
    cjk_count = len(RX_CJK_PATTERN.findall(text))
    
    # 其餘字元(英文、數字、程式碼標點、半形空白等)
    other_chars_count = len(text) - cjk_count

    # 中文每字約 1.3 token,其餘每 3.5 字元約 1 token
    return int((cjk_count * 1.3) + (other_chars_count / 3.5))

...

一點點拼出狀態顯示

馬上,我們來實作負責產生顯示文字的 get_context_status_text()!

調用的時機

先說一下它被調用的時機,方便下面的理解:

  1. 畫面初次渲染:
[ main.py 執行 get_input(),呼叫 prompt("> ") ]
                     │
                     ▼
[ 1. prompt_toolkit 繪製初始終端畫面與輸入提示字元 "> " ]
                     │
                     ▼
[ 2. 佈局引擎準備繪製右側 rprompt,向回呼函式要文字 ]
                     │
                     ▼
[ 3. 執行 _dynamic_rprompt(),讀取當前緩衝區文字(此時為空字串 "") ]
                     │
                     ▼
[ 4. 首次呼叫 get_context_status_text(user_input="") ]
     • 讀取目前 System Prompt + 歷史對話(或 Ollama 的 true_token)
     • 計算出此時的基準歷史水位
                     │
                     ▼
[ 5. 終端機最右側印出初始狀態,例如:[2.4k/16.0k] 15.3% ]
  1. 打字過程:
[ 使用者敲擊鍵盤、貼上大段程式碼、或按 Backspace 刪字 ]
                     │
                     ▼
[ 1. 捕捉鍵盤事件,文字寫入輸入緩衝區(Buffer) ]
                     │
                     ▼
[ 2. 緩衝區內容改變,觸發終端機「重繪畫面(Redraw)」 ]
                     │
                     ▼
[ 3. 重新計算右側提示:主動執行回呼函式 _dynamic_rprompt() ]
                     │
                     ▼
[ 4. 再次呼叫 get_context_status_text(user_input=最新打的字) ]
     • 基礎歷史基準 + 提示詞差量 + 當前打字的預估 Token
                     │
                     ▼
[ 5. 終端機右側即時跳動刷新,例如跳至:[3.8k/16.0k] 23.7% ]
  1. 新的一輪開始(使用者繼續輸入前):
[ 使用者按下 Enter 送出這輪問題 ]
                     │
                     ▼
[ 1. 當前輸入行與右側數值「定格」在終端機上,開始執行 model.chat() ]
                     │
                     ▼
[ 2. 模型思考、調用工具、輸出完畢,Ollama 傳回真實 Token 存入 true_token ]
                     │
                     ▼
[ 3. main.py 的 while 迴圈重新開始,再次呼叫 get_input() ]
                     │
                     ▼
[ 4. 重新觸發「時機一」,此時 get_context_status_text() 呼叫時 ]
     直接以剛拿到的 Ollama 真實 Token 作為全新的起始基準!

取得 system prompt

為什麼要新舊兩個都拿呢?
當使用者輸入問題,模型取得了第一版 system prompt,而第二次輸入時,使用者更改了 system prompt,我們要計算這兩個版本的 token 差距,來較精準地估算出 token 數。

這邊不能用 chat() 裡拿的 system prompt,因為在此時,chat() 還未被呼叫。
但有注意到嗎?此處呼叫與 Agent.chat() 內部傳給 get_system_prompt() 的參數不大一樣(沒有特別過濾 tool_list),這是我為了省麻煩偷的懶,
在此處估算時不追求極度嚴苛的工具過濾(直接預設允許全部工具)。

true_token 是由 ollama 回傳的真實 token 數量,先不管它怎麼來的,我們晚點再看。

# src/meowgent/agent.py
...
class Agent():
	...
	def __init__(...):
		...
		self.true_token: Optional[int] = None # 由 ollama 回傳的真實 token 數量
		self.last_system_prompt: Optional[str] = None # 記錄上次生效的提示詞

	def get_context_status_text(
		self,
		user_input: Optional[str] = None
	) -> Optional[str]:
        """ 獲取上下文佔用文字 """
        
        system_prompt = get_system_prompt(
            model_name=self.model_name,
            path=self.path,
            rule=self.rule,
            enable_tools=True
        )

獲取最大上下文

這邊,我把最大上下文也加到了設定檔之中:

# src/meowgent/config/config_schema.py
...

class ModelsConfig(BaseModel):
	...
	
	max_context: int = Field(
        default=16384,
        ge=0,
        description="模型的最大上下文 token"
    )
...
# src/meowgent/cli/main.py
...
if __name__ == "__main__":
	...
	
	model = Agent(
        OllamaProvider(
            ...,
            context=config.models.max_context
        ),
        ...
    )
# src/meowgent/providers/ollama_provider.py
...
class OllamaProvider(...):
    def __init__(..., context: int = 16384):
        ...
        self.context = context
    
    def stream_generate(...) -> ...:
	    response = ollama.chat(
            ...,
            options={
                "num_ctx": self.context,
                ...
            }
        )

然後,我們就可以取出 OllamaProvider 物件裡的 self.context 了:

# src/meowgent/agent.py
...
class Agent():
	...
	
	def get_context_status_text(...) -> ...:
		...
		
		max_context = getattr(self.provider, "context", 16384)
		# 獲取 OllamaProvider 物件的 self.context
		
        max_context_k = round(max_context / 1000, 1) # 轉換為 k,並四捨六入

已經有真實數據時

如果已經有了 Ollama 傳來的真實 token,我們會以此為基礎來做計算。

回傳說截至上次,提示詞輸入加上輸出佔據了 10k,而我們繼續以這個 10k 來做計算,
簡單來說,「已經有準確答案的部分,就不要用估算的」。

而這邊還要處理「system prompt」更新的問題,當新舊 prompt 不同時,要把差值一併計算進去。

而 system prompt 以及使用者輸入的部分,是由 estimate_token() 估算得來的。

# src/meowgent/agent.py
...
class Agent():
	...
	
	def get_context_status_text(...) -> ...:
		...
		
		if self.true_token: 

            system_prompt_token_gap = 0
            if self.last_system_prompt and self.last_system_prompt != system_prompt:
                system_prompt_token_gap = estimate_token(
	                system_prompt
                ) - estimate_token(self.last_system_prompt)
                # 算出舊的跟新的差多少

            input_token = estimate_token(user_input) if user_input else 0

            total_token = self.true_token + system_prompt_token_gap + input_token
            # 已有的真實 token(前幾輪)+ 新的 sys prompt 要補(扣)的 + 當前輸入

無真實數據

也就是在第一輪輸入時,全部的內容都只能靠 estimate_token() 來估算:

# src/meowgent/agent.py
...
class Agent():
	...
	
	def get_context_status_text(...) -> ...:
		...
		if ...:
			...
		
		else:
            
            total_text = system_prompt + (user_input or "")

            total_token = estimate_token(total_text)

收尾

最後,把 total_token 轉換為 k 的單位,做四捨六入,以及算出佔用百分比,就可以回傳文字啦!

# src/meowgent/agent.py
...
class Agent():
	...
	
	def get_context_status_text(...) -> ...:
		...
		total_token_k = round(total_token / 1000, 1) # 單位轉為 k

        percentage = round((total_token / max_context) * 100, 1)
        
        return f"[{total_token_k}k/{max_context_k}k] {percentage}%"

今天我們先搞定了底層的 token 估算函數,以及核心邏輯 - get_context_status_text()!
明天,我們就要把這個功能正式接上 prompt_toolkit 的右側動態提示(rprompt),讓它能隨著輸入打字即時更新!
(本來想說,上下文用量這部分可以一篇搞定,結果寫著寫著發現內容還不少啊,而且又要寫不完了~)


上一篇
Day 25 - 利用 subagent 做模型分工 - 下
下一篇
Day 27 - 上下文用量 - 下 + 中斷模型回答
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言